/*
 *
 *  Copyright (C) 2010 Tal Eisenberg
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package tal.drivechain.chain.script;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import tal.drivechain.chain.AbortChainException;
import tal.drivechain.chain.Chain;
import tal.drivechain.chain.ChainNode;
import tal.drivechain.chain.FreePatcher;
import tal.drivechain.chain.NodeExport;
import tal.drivechain.chain.ParallelPatcher;
import tal.drivechain.chain.Patcher;
import tal.drivechain.chain.SerialPatcher;
import tal.drivechain.parsing.Tokenizer;
import tal.drivechain.parsing.ParsingAid;
import tal.drivechain.parsing.ModuleCommandParser;
import tal.drivechain.plugin.Module;
import tal.drivechain.plugin.ModulePackageManager;
import tal.drivechain.system.Globals;
import tal.drivechain.system.PluginLoader;
import tal.drivechain.system.Version;
import tal.drivechain.variable.SetProperty;
import tal.drivechain.variable.SetProperty.Flag;
import tal.drivechain.variable.Variable;
import tal.drivechain.wildcard.VersionElement;
import tal.drivechain.wildcard.Wildcard;

/**
 *
 * @author Main
 */
public class ScriptCompiler {

    private String script;
    private List<ScriptCommand> scriptCommands;
    private File scriptFilePath = null;
    private boolean suppressConnectionErrors = false;

    /**
     * Creates a script compiler with no loaded script.
     */
    public ScriptCompiler() {
    }

    /**
     * Creates a script compiler and sets the parameter script as currently loaded script. Other methods need to be called
     * to compile the script.
     *
     * @param script A string containing a chain script.
     */
    public ScriptCompiler(String script) {
        this.script = script;
    }

    /**
     * Sets the script string for this compiler.
     * You must load a script either using this method or loadFromFile() method before doing any compiling job.
     *
     * @param script A string containing a chain script.
     */
    public void setScript(String script) {
        this.script = script;
    }

    public void setScriptFilePath(File path) {
        this.scriptFilePath = path;
    }

    public File getScriptFilePath() { return this.scriptFilePath; }

    /**
     *
     * @return the currently loaded script in this compiler or null if no script was loaded.
     */
    public String getScript() {
        return script;
    }

    /**
     *
     * @return A list of commands in the loaded script or null if the script is not parsed yet or no script is loaded.
     */
    public List<ScriptCommand> getScriptCommands() {
        return scriptCommands;
    }

    /**
     * Parses the currently loaded script string. Creates a meta data object and a list of script commands.
     * You must call loadFromFile() or setScript() before calling this method.
     *
     * @throws java.lang.IllegalArgumentException If no script is currently loaded in this compiler.
     */
    public void parseScript() throws IllegalArgumentException {
        if (script == null) {
            throw new IllegalArgumentException("Load a script file / string before trying to parse it.");
        }
        scriptCommands = tokenizeCommands(script);
    }

    /**
     * 
     * @param parent The chain structure will be created under this chain.
     * @return Compiled chains. Each chain command is returned as a Chain object in the array.
     * @throws IllegalArgumentException
     * @throws AbortChainException
     */
    public Chain[] compileScript(Chain parent) throws IllegalArgumentException, AbortChainException {
//        System.out.println("compiling script from: " + (scriptFilePath!=null?this.getScriptFilePath().toString():"unknown"));
        List<Chain> chains = new ArrayList<Chain>();

        if (scriptCommands == null) {
            throw new IllegalArgumentException("The script must be parsed before creating a chain.");
        } else if (scriptCommands.isEmpty()) {
            throw new AbortChainException("Script does not contain any commands");
        }

        for (ScriptCommand c : scriptCommands) {
            if (c!=null) {
                if (c.getName().equals("chain") || c.getArg1().equals("chain")) {
                    Chain chain = parseChainCommand(parent, c);
                    if (chain != null) chains.add(chain);
                } else if (c.getName().equals("##"));
                else throw new IllegalArgumentException("Unknown script command: " + c.toString());
            }
        }
//        System.out.println("finished compiling");
        return chains.toArray(new Chain[0]);
    }

    public Chain parseChainCommand(Chain parent, ScriptCommand c) throws AbortChainException, IllegalArgumentException {
//        System.out.println("parsing " + c.toString());
   

        String chainName, patcherName, chainScript;
        String chainDef = null;
        if (c.getName().equals("chain")) { // a chain without a patcher modifier will use FreePatcher
            chainDef = c.getArg1()+c.getArg2();
            patcherName = null;
        } else if (c.getArg1().equals("chain")) {
            patcherName = c.getName();
            chainDef = c.getArg2();
        } else throw new IllegalArgumentException("Invalid chain command syntax: " + c);

        int openIdx = ParsingAid.indexOfUnnested(chainDef, '{', "([{'", ")]}'");
        if (openIdx==-1) {
            chainScript = null;
            chainName = chainDef;
        } else {
            int closeIdx = Tokenizer.balanced(chainDef, openIdx, '{', '}');
            if (closeIdx==-1)
                throw new AbortChainException("Missing closing parenthesis '}':" + c);

            chainName = chainDef.substring(0, openIdx).trim();
            chainScript = chainDef.substring(openIdx+1, closeIdx).trim();
        }

//        System.out.println("chainName=" + chainName);
        int lessIdx = chainName.indexOf("<");
        if (lessIdx!=-1) { // loads a chain from file by reading the filename between < and >.
//            System.out.println("PARSING AS LOAD FROM FILE");
            int greaterIdx = chainName.indexOf(">", lessIdx);
            if (greaterIdx==-1) throw new IllegalArgumentException("Missing closing '>' in chain filename.");
            String filename = chainName.substring(lessIdx+1, greaterIdx).trim();
            String chainInFile = null;
            int atIdx = filename.indexOf("@");
            if (atIdx==-1) chainInFile = null;
            else {
                chainInFile = filename.substring(atIdx+1);
                filename = filename.substring(0, atIdx);
            }
            chainName = chainName.substring(0, lessIdx).trim();

            ScriptCompiler childCompiler = new ScriptCompiler();
            childCompiler.loadFromFile(ScriptCompiler.getScriptFile(filename, scriptFilePath, false));
            childCompiler.parseScript();
            Chain[] chains = childCompiler.compileScript(parent);
            Chain finalChain;
            if (chainInFile==null && chains.length>1)
                throw new IllegalArgumentException("Script " + scriptFilePath + " has more than one chain. Add a chain name to load from the file.");
            else if (chainInFile==null) {
                for (int i=1; i<chains.length; i++)
                    chains[i].destroy();

                parent.renameChild(chains[0], chainName);
                chains[0].isLoadedFromFile = true;
                finalChain = chains[0];
            } else {
                Chain retChain = null;
                for (Chain chain : chains) {
                    if (chain.getName().equals(chainInFile)) retChain = chain;
                    else chain.destroy();
                }
                if (retChain==null) throw new IllegalArgumentException("Can't find chain " + chainInFile + " in script file " + scriptFilePath);
                else {
                    parent.renameChild(retChain, chainName);
                    retChain.isLoadedFromFile = true;
                    finalChain = retChain;
                }
            }

            // set properties.
            if (chainScript!=null && chainScript.length()>0) {
                SetProperty setProp = new SetProperty("Chain", chainScript, Flag.REPLACE);
                setProp.invokeSetProperty(finalChain);
            }

            return finalChain;
        } else { // creates a chain by reading a script within curly brackets.
//            System.out.println("PARSING AS CHAIN FROM SCRIPT");
            Patcher patcher;
            if (patcherName==null || patcherName.equals("free"))
                patcher = new FreePatcher();
            else if (patcherName.equals("parallel")) {
                patcher = new ParallelPatcher();
            } else if (patcherName.equals("serial")) {
                patcher = new SerialPatcher();
            } else {
                throw new AbortChainException("Unknown patcher '" + patcherName + "' in chain command: " + c);
            }

            return compileChain(chainName, parent, patcher, chainScript);
        }
    }

    /**
     * Creates a chain from currently loaded and parsed script and adds it as a child of the given chain.
     * @param parent The new compiled chain will be added as a child of this chain.
     * @requestedName overrides the returned chain's name.
     * @return A new chain, compiled from the script in this compiler.
     * @throws tal.drivechain.chain.AbortChainException If an error occurs and the compiler can't continue creating the chain.
     */
    public Chain compileChain(String chainName, Chain parent, Patcher patcher, String chainScript) throws IllegalArgumentException, AbortChainException {

        // create the chain object.
        Chain chain = new Chain(chainName);
        if (parent!=null) parent.addChild(chain);

        if (chainScript != null) {
            List<ScriptCommand> vars = new ArrayList<ScriptCommand>();
            List<ScriptCommand> modules = new ArrayList<ScriptCommand>();
            List<ScriptCommand> patches = new ArrayList<ScriptCommand>();
            List<ScriptCommand> exports = new ArrayList<ScriptCommand>();
            List<ScriptCommand> metaData = new ArrayList<ScriptCommand>();
            List<ScriptCommand> chains = new ArrayList<ScriptCommand>();

            List<ScriptCommand> chainCommands = tokenizeCommands(chainScript);
            if (chainCommands.isEmpty()) {
                throw new AbortChainException("Chain " + chainName + " script does not contain any commands");
            }
            //sort commands by command name
            for (ScriptCommand command : chainCommands) {
                if (command.getName().equals("chain") || command.getArg1().equals("chain")) {
                    chains.add(command);
                } else if (command.getName().equals("variable") || command.getName().equals("var")) {
                    vars.add(command);
                } else if ((command.getName().equals("public") || command.getName().equals("private")) && (command.getArg1().equals("var") || command.getArg1().equals("variable"))) {
                    vars.add(command);
                } else if (command.getName().equals("export")) {
                    exports.add(command);
                } else if (command.getName().equals("module") || command.getName().equals("mod")) {
                    if (command.getArg2().equals("") || command.getArg1().equals("")) {
                        throw new AbortChainException("Bad syntax for module script command: " + command);
                    }
                    modules.add(command);
                } else if (command.getName().equals("**")) { // meta-data lines
                    metaData.add(command);
                } else if (command.getName().equals("##")) {
                    // add ## to the beginning of a command to make the compiler ignore it.
                } else {// add everything else as a patcher configuration command
                    patches.add(command);
                }
            }


            // parse meta-data
            for (ScriptCommand meta : metaData) {
                // ** key: value
                String def = meta.getArg1() + meta.getArg2();
                int colonIdx = def.indexOf(":");
                if (colonIdx==-1) throw new IllegalArgumentException("Invalid meta-data command: " + meta);

                String key = def.substring(0, colonIdx).trim();
                String value = def.substring(colonIdx+1).trim();
                chain.putMetadata(key, value);
            }

            if (chain.getMetadataMap().containsKey(".depends-on"))
                checkScriptDependencies(chain.getMetadata(".depends-on"));

            chain.setScriptFilePath(scriptFilePath);

            //define chain/public vars

            for (ScriptCommand varCmd : vars) {
                String vardef;
                boolean isPublic = false;
                if (varCmd.getName().equals("public")) {
                    vardef = varCmd.getArg2();
                    isPublic = true;
                } else if (varCmd.getName().equals("private")) {
                    vardef = varCmd.getArg2();
                } else {
                    vardef = varCmd.getArg1() + varCmd.getArg2();
                }
                Variable var = Variable.parseVariable(null, null, vardef, false);
                if (!isPublic) {
                    chain.storeChainVar(var.getName(), var);
                } else {
                    chain.storePublicVar(var.getName(), var);
                }
            }

            // compile children chains
            for (ScriptCommand chainCmd : chains) {
                this.parseChainCommand(chain, chainCmd);
            }

            // load module objects
            for (ScriptCommand strModule : modules) {
                ScriptCompiler.parseModuleCommand(chain, strModule);
            }

            //define exported properties
            for (ScriptCommand export : exports) {
                chain.addNodeExport(NodeExport.parseModuleExport(export, chain));
            }

            //patch the chain
            try {
                patcher.configure(patches);
                chain.patchNodes(patcher);
            } catch (IllegalArgumentException e) {
                if (!this.suppressConnectionErrors)
                    throw e;
                else System.out.println("Suppressing: " + e);
            } catch (AbortChainException e) {
                if (!this.suppressConnectionErrors)
                    throw e;
                else System.out.println("Suppressing: " + e);
            }
            chain.setupProperties();
        }
        
//        System.out.println("finished compiling chain");
        return chain;
    }

    /**
     * Reads the file and set the compiler loaded script from the contents of the file. parseScript() is still needed before
     * creating a chain from this file. Doesn't check the contents of the file.
     *
     * @param file a file object
     * @return The contents of this file
     * @throws java.lang.IllegalArgumentException If an error occurs while reading the file.
     */
    public void loadFromFile(File file) throws IllegalArgumentException {
//        System.out.println("Reading script file " + file.getAbsolutePath());
        BufferedReader input = null;
        StringBuilder contents = new StringBuilder();
        try {
            try {
                input = new BufferedReader(new FileReader(file));
            } catch (FileNotFoundException ex) {
                throw new IllegalArgumentException("File not found: " + file.getAbsolutePath());
            }
            String line = null;
            while ((line = input.readLine()) != null) {
                contents.append(line).append(System.getProperty("line.separator"));
            }
        } catch (IOException ex) {
            throw new IllegalArgumentException(ex.getMessage());
        } finally {
            try {
                if (input != null) {
                    input.close();
                }
            } catch (IOException ex) {
                throw new IllegalArgumentException(ex.getMessage());
            }
        }
        script = contents.toString();
        scriptFilePath = file;

    }

    /**
     * Finds a script file according to the parameter. If filename is not an absolute path and
     * a scripts path is set in the Globals class the file is searched relative to the scripts path.
     * if the script is then not found in the scripts path it will try to find it in the directory of the refPath file.
     * @param filename a path to the script file.
     * @param refPath The method will search in the parent directory of refPath if the file is not found elsewhere.
     * @return A File object that points to the found script file.
     * @throws java.lang.IllegalArgumentException If the file is not found.
     */
    public static File getScriptFile(String filename, File refPath, boolean newFile) {
        File f = new File(filename);
        if (!f.isAbsolute() && Globals.getScriptsPath() != null) {
            f = new File(Globals.getScriptsPath() + filename);
        }
        if (f.exists() || newFile) {
            return f;
        } else {
            // try using the reference path instead
            f = new File(filename);
            if (!f.isAbsolute() && refPath!=null) {
                f = new File(refPath.getParent() + System.getProperty("file.separator") + filename);
                if (f.exists() || newFile)
                    return f;
                else throw new IllegalArgumentException("File " + filename + " was not found. ");
            } else
                throw new IllegalArgumentException("File " + filename + " was not found. ");
        }
    }

    public static File getScriptFile(String filename, boolean newFile) {
        return getScriptFile(filename, null, newFile);
    }

    /**
     * Parses a semicolon delimited script commands list into separate ScriptCommand objects.
     * @param commands
     * @return
     */
    private List<ScriptCommand> tokenizeCommands(String commands) {
        scriptCommands = new ArrayList<ScriptCommand>();
        Tokenizer tokenizer = new Tokenizer(commands, ';');
        while (tokenizer.hasMoreTokens()) {
            String token = tokenizer.nextToken();
            if (token.length() != 0) {
                scriptCommands.add(ScriptCommand.parseScriptCommand(token));
            }
        }
        return scriptCommands;
    }

    /**
     * Parses a module script command in the format of: mod/ule instanceName { properties };
     *
     * @param chain Adds the new module to this chain.
     * @param cmdModule The command to parse.
     * @return A new module created according to the script command.
     * @throws tal.drivechain.chain.AbortChainException If an error prevents from setting up this module.
     */
    public static Module parseModuleCommand(Chain chain, ScriptCommand cmdModule) throws AbortChainException {
        ModuleCommandParser parser = new ModuleCommandParser(cmdModule.getArg2());
        Module module = PluginLoader.getModule(parser.getModuleName());
        module.setName(cmdModule.getArg1());
        module.parseProperties(parser.getModuleProperties());

        chain.addChild(module);
        return module;
    }

    public ChainNode parseChainNodeCommand(Chain chain, ScriptCommand cmdNode) throws AbortChainException {
        if (cmdNode.getName().equals("module") || cmdNode.getName().equals("mod"))
            return parseModuleCommand(chain, cmdNode);
        else return this.parseChainCommand(chain, cmdNode);
    }

    /**
     * Makes sure that every script dependency is present in the system.
     * @param dependsOn A requires string (as added by Chain.createMetaData()), A comma delimited list of version wildcards
     * @return True if every script dependency is present.
     * @throws java.lang.IllegalArgumentException when a script dependency is missing or a syntax error occurs.
     */
    private boolean checkScriptDependencies(String dependsOn) throws IllegalArgumentException {
        String[] wcs = new Tokenizer(dependsOn, ',').getTokens();
        for (String req : wcs) {
            int slashIdx = req.indexOf('/');
            if (slashIdx == -1)
                throw new IllegalArgumentException("Invalid syntax for requires attribute. should be 'requires: name/[version],...,name/[version]'");

            Wildcard dep = Wildcard.parseDefinition(req.substring(slashIdx + 1), new VersionElement());
            String name = req.substring(0, slashIdx);
            
            if (name.equals(Globals.VERSION.getSrcName())) {
                if (!dep.matches(Globals.VERSION.getVersionLevel(), null)) {
                    throw new IllegalArgumentException("This script requires drivechain version " + dep.toString() + ". Currently running version is " + Globals.VERSION);
                }
            } else {
                ModulePackageManager q = PluginLoader.getPackageManagers().get(name);
                if (q == null) {
                    throw new IllegalArgumentException("Missing script dependency: module package " + name + "/" + dep);
                } else {
                    Version ver = q.getPackageConfiguration().getPackageInfo().getVersion();
                    if (!dep.matches(ver.getVersionLevel(), null)) {
                        throw new IllegalArgumentException("Missing script dependency: " + name + " " + dep.getWildcardString(null) +
                                " (found: " + ver.toString(true, true, true, false));
                    }
                }
            }
        }
        return true;
    }

    public HashMap<String,String> compileMetaData() {
        if (scriptCommands==null) throw new IllegalArgumentException("Script must be parsed before its meta-data can be extracted.");
        HashMap<String,String> data = new HashMap<String,String>();

        for (ScriptCommand command : scriptCommands) {
            if (command.getName().equals("**")) {
                String def = command.getArg1() + command.getArg2();
                int colonIdx = def.indexOf(":");
                if (colonIdx==-1) throw new IllegalArgumentException("Invalid meta-data command: " + command);

                String key = def.substring(0, colonIdx).trim();
                String value = def.substring(colonIdx+1).trim();
                data.put(key, value);
            }
        }

        return data;
    }

    public void suppressConnectionErrors(boolean b) {
        this.suppressConnectionErrors = b;
    }
}
